<?php
/*
 * Copyright (c) 2013 Eugene Kuzmenko
 *
 * Permission  is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in  the Software without restriction, including without limitation the rights
 * to  use,  copy,  modify,  merge, publish, distribute, sublicense, and/or sell
 * copies  of  the  Software,  and  to  permit  persons  to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The  above  copyright  notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE  SOFTWARE  IS  PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED,  INCLUDING  BUT  NOT  LIMITED  TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS  FOR  A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS  OR  COPYRIGHT  HOLDERS  BE  LIABLE  FOR  ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 *
 * For more information, see <http://www.aljey.com>.
 */

namespace Al\Core\Lib\DOM;

use Al\Core\Lib\ParameterHolder;

/**
 * Maintains a list of dom nodes. Exposes useful jQuery-like capabilities.
 *
 * @author Eugene Kuzmenko <mail@aljey.com>
 */
class NodeList extends ParameterHolder {

  /**
   * @var \DOMNodeList A collection of dom nodes.
   */
  private $nodeList;

  /**
   * @var Query The document reference.
   */
  private $query;

  /**
   * {@inheritDoc}
   * @param \DOMNodeList  $nodeList
   */
  public function __construct(\DOMNodeList $nodeList, Query $query) {
    $this->nodeList = $nodeList;
    $this->query = $query;
    $data = array();
    for ($i = 0; $i < $nodeList->length; $i++) {
      $data[] = $nodeList->item($i);
    }
    parent::__construct($data);
  }

  /**
   * Returns a reference to the document.
   *
   * @return \DOMDocument The document object.
   */
  public function getDocument() {
    return $this->query->getDocument();
  }

  /**
   * {@inheritDoc}
   * Physically removes a node from the dom.
   */
  public function offsetUnset($offset) {
    $node = $this[$offset];
    $node->parentNode->removeChild($node);
    parent::offsetUnset($offset);
  }

  /**
   * {@inheritDoc}
   * Physically replaces a node within the dom. Push is not supported.
   */
  public function offsetSet($offset, $value) {
    if (($value instanceof \DOMNode) &&
        !is_null($old_value = $this->offsetGet($offset))) {
      $old_value->parentNode->replaceChild($value, $old_value);
      parent::offsetSet($offset, $value);
    }
  }

  /**
   * Adds the specified class to each of the set of matched nodes.
   *
   * @param string $cls A class name.
   * @return NodeList $this
   */
  public function addClass($cls) {
    $cls = preg_split('/\s+/', (string)$cls, -1, PREG_SPLIT_NO_EMPTY);
    foreach ($this as $node) {
      if (!($node instanceof \DOMElement)) continue;
      $classList = preg_split('/\s+/', $node->hasAttribute('class') ? $node
        ->getAttribute('class') : '', -1, PREG_SPLIT_NO_EMPTY);
      foreach ($cls as $class) {
        if (!in_array($class, $classList, true)) {
          $classList[] = $class;
        }
      }
      $node->setAttribute('class', implode(' ', $classList));
    }
    return $this;
  }

  /**
   * Insert content, specified by the parameter, after each node in the set of
   * matched nodes.
   *
   * @param mixed $value A node, a set of nodes, a css selector or an html
   *                     string.
   * @return NodeList $this
   */
  public function after($value) {
    $query = $this->query;
    /** @var \DOMNode $node */
    foreach ($this as $node) {
      $next = $node->nextSibling;
      $parent = $node->parentNode;
      foreach ($query($value) as $item) {
        if ($next) {
          $parent->insertBefore($item, $next);
        } else {
          $parent->appendChild($item);
        }
      }
    }
    return $this;
  }

  /**
   * Insert content, specified by the parameter, to the end of each node in the
   * set of matched nodes.
   *
   * @param mixed $value A node, a set of nodes, a css selector or an html
   *                     string.
   * @return NodeList $this
   */
  public function append($value) {
    $query = $this->query;
    foreach ($this as $node) {
      if ($node instanceof \DOMElement) {
        foreach ($query($value) as $item) {
          $node->appendChild($item);
        }
      }
    }
    return $this;
  }

  /**
   * Insert every node in the set of matched nodes to the end of the target.
   *
   * @param mixed $value A node, a set of nodes, a css selector or an html
   *                     string.
   * @return NodeList $this
   */
  public function appendTo($value) {
    $query = $this->query;
    foreach ($query($value) as $item) {
      if ($item instanceof \DOMElement) {
        foreach ($this as $node) {
          $item->appendChild($node);
        }
        break;
      }
    }
    return $this;
  }

  /**
   * Get the value of an attribute for the first node in the set of matched
   * nodes.
   * Set one or more attributes for the set of matched nodes.
   *
   * @param \Traversable|array|string $name
   * @param string $value [optional]
   * @return NodeList|string
   */
  public function attr($name, $value = null) {
    if (!($name instanceof \Traversable) && !is_array($name)) {
      if (is_null($value)) {
        foreach ($this as $node) {
          if ($node instanceof \DOMElement) {
            return $node->hasAttribute($name) ? $node->getAttribute($name) : '';
          }
        }
        return '';
      }
      $name = array((string)$name => $value,);
    }
    foreach ($this as $node) {
      if ($node instanceof \DOMElement) {
        foreach ($name as $key => $value) {
          $node->setAttribute($key, (string)$value);
        }
      }
    }
    return $this;
  }

  /**
   * Insert content, specified by the parameter, before each node in the set
   * of matched nodes.
   *
   * @param mixed $value A node, a set of nodes, a css selector or an html
   *                     string.
   * @return NodeList $this
   */
  public function before($value) {
    /** @var \DOMNode $node */
    foreach ($this as $node) {
      $parent = $node->parentNode;
      $query = $this->query;
      $val = $query($value);
      foreach ($val as $item) {
        $parent->insertBefore($item, $node);
      }
    }
    return $this;
  }

  /**
   * Get the children of each node in the set of matched nodes, optionally
   * filtered by a selector.
   *
   * @param string $selector [optional] A css selector.
   * @return NodeList A list of nodes.
   */
  public function children($selector = null) {
    $query = $this->query;
    $list = new NodeList(new \DOMNodeList(), $query);
    foreach ($this as $node) {
      if (!($node instanceof \DOMElement)) continue;
      $temp = is_null($selector) ? new NodeList($node->childNodes, $query) :
        $query($selector, $node);
      foreach ($temp as $item) {
        $list[] = $item;
      }
    }
    return $list;
  }

  /**
   * Create a deep copy of the set of matched nodes. Unlike in javascript the
   * word "clone" is reserved in php, that's why the function has been renamed
   * to "cloneNodes".
   *
   * @return NodeList A list of nodes.
   */
  public function cloneNodes() {
    $list = new NodeList(new \DOMNodeList(), $this->query);
    /** @var \DOMNode $node */
    foreach ($this as $node) {
      $list[] = $node->cloneNode(true);
    }
    return $list;
  }

  /**
   * Remove all child nodes of the set of matched nodes from the DOM. Unlike
   * in javascript the word "empty" is reserved in php, that's why the function
   * has been renamed to "emptyNodes".
   *
   * @return NodeList $this
   */
  public function emptyNodes() {
    $children = $this->children();
    $len = count($children);
    for ($i = 0; $i < $len; $i++) {
      unset($children[$i]);
    }
    return $this;
  }

  /**
   * Reduce the set of matched nodes to the one at the specified index.
   *
   * @param integer $index The index in the original list.
   * @return NodeList A new node list.
   */
  public function eq($index) {
    $list = new NodeList(new \DOMNodeList(), $this->query);
    if (!is_int($index)) return $list;
    $len = count($this);
    if ($index < 0) {
      $index += $len;
    }
    if ($index > ($len - 1)) return $list;
    $list[] = $this[$index];
    return $list;
  }

  /**
   * Get the descendants of each node in the current set of matched nodes,
   * filtered by a selector. A convenience method, identical to "children" in
   * every way except that the selector isn't optional.
   *
   * @param string $selector A css selector.
   * @return NodeList A list of nodes.
   */
  public function find($selector) {
    return $this->children($selector);
  }

  /**
   * Reduce the set of matched nodes to the first in the set.
   *
   * @return NodeList A list of nodes.
   */
  public function first() {
    $this->rewind();
    return $this->eq($this->key());
  }

  /**
   * Determine whether any of the matched nodes are assigned the given class.
   *
   * @param string $cls A class name.
   * @return bool
   */
  public function hasClass($cls) {
    $cls = preg_split('/\s+/', (string)$cls, -1, PREG_SPLIT_NO_EMPTY);
    foreach ($this as $node) {
      if (!($node instanceof \DOMElement)) continue;
      $classList = preg_split('/\s+/', $node->hasAttribute('class') ? $node
        ->getAttribute('class') : '', -1, PREG_SPLIT_NO_EMPTY);
      $has = true;
      foreach ($cls as $class) {
        if (!in_array($class, $classList, true)) {
          $has = false;
        }
      }
      if (!$has) continue;
      return true;
    }
    return false;
  }

  /**
   * Get the HTML contents of the first node in the set of matched nodes.
   * Set the HTML contents of each node in the set of matched nodes.
   *
   * @param mixed $value [optional] A node, a set of nodes, a css selector or an
   *                     html string.
   * @return string|NodeList
   */
  public function html($value = null) {
    $query = $this->query;
    if (is_null($value)) {
      $this->rewind();
      $node = $this->current();
      if ($node instanceof \DOMElement) {
        $list = new NodeList($node->childNodes, $query);
        $response = '';
        foreach ($list as $item) {
          $response .= $query->__toString($item);
        }
        return $response;
      }
      return $query->__toString($node);
    }
    foreach ($this as $node) {
      if ($node instanceof \DOMElement) {
        /** @var NodeList $list */
        $list = new NodeList(new \DOMNodeList(), $query);
        $list[] = $node;
        $list->emptyNodes();
        foreach ($query($value) as $item) {
          $list->append($item);
        }
      }
    }
    return $this;
  }

  /**
   * Insert every node in the set of matched nodes after the target.
   *
   * @param mixed $value A node, a set of nodes, a css selector or an html
   *                     string.
   * @return NodeList $this
   */
  public function insertAfter($value) {
    $query = $this->query;
    /** @var \DOMNode $item */
    foreach ($query($value) as $item) {
      $parent = $item->parentNode;
      $next = $item->nextSibling;
      foreach ($this as $node) {
        if ($next) {
          $parent->insertBefore($node, $next);
        } else {
          $parent->appendChild($node);
        }
      }
    }
    return $this;
  }

  /**
   * Insert every node in the set of matched nodes before the target.
   *
   * @param mixed $value A node, a set of nodes, a css selector or an html
   *                     string.
   * @return NodeList $this
   */
  public function insertBefore($value) {
    $query = $this->query;
    /** @var \DOMNode $item */
    foreach ($query($value) as $item) {
      $parent = $item->parentNode;
      foreach ($this as $node) {
        $parent->insertBefore($node, $item);
      }
      break;
    }
    return $this;
  }

  /**
   * Reduce the set of matched nodes to the final one in the set.
   *
   * @return NodeList A list of nodes.
   */
  public function last() {
    $this->end();
    return $this->eq($this->key());
  }

  /**
   * Get the immediately following sibling element of each node in the set of
   * matched nodes. "next" is reserved by the Iterator interface. Unlike it's
   * javascript counterpart this method does not accept a css selector.
   *
   * @return NodeList A list of nodes.
   */
  public function nextElement() {
    $list = new NodeList(new \DOMNodeList(), $this->query);
    foreach ($this as $node) {
      $next = $node->nextSibling;
      while ($next && !($next instanceof \DOMElement)) {
        $next = $next->nextSibling;
      }
      if ($next instanceof \DOMElement) {
        $list[] = $next;
      }
    }
    return $list;
  }

  /**
   * Get all the immediately following sibling elements of each node in the set
   * of matched nodes. Unlike it's javascript counterpart this method does not
   * accept a css selector.
   *
   * @return NodeList A list of nodes.
   */
  public function nextAllElements() {
    $list = new NodeList(new \DOMNodeList(), $this->query);
    foreach ($this as $node) {
      $next = $node->nextSibling;
      while ($next) {
        if ($next instanceof \DOMElement) {
          $list[] = $next;
        }
        $next = $next->nextSibling;
      }
    }
    return $list;
  }

  /**
   * Get the parent of each node in the current set of matched nodes. Unlike
   * it's javascript counterpart this method does not accept a css selector.
   *
   * @return NodeList
   */
  public function parent() {
    $list = new NodeList(new \DOMNodeList(), $this->query);
    foreach ($this as $node) {
      $list[] = $node->parentNode;
    }
    return $list;
  }

  /**
   * Insert content, specified by the parameter, to the beginning of each
   * element in the set of matched nodes.
   *
   * @param mixed $value A node, a set of nodes, a css selector or an html
   *                     string.
   * @return NodeList $this
   */
  public function prepend($value) {
    $query = $this->query;
    foreach ($this as $node) {
      if ($node instanceof \DOMElement) {
        $first = $node->firstChild;
        foreach ($query($value) as $item) {
          if ($first) {
            $node->insertBefore($item, $first);
          } else {
            $node->appendChild($item);
          }
        }
      }
    }
    return $this;
  }

  /**
   * Insert every node in the set of matched nodes to the beginning of the
   * target.
   *
   * @param mixed $value A node, a set of nodes, a css selector or an html
   *                     string.
   * @return NodeList $this
   */
  public function prependTo($value) {
    $query = $this->query;
    foreach ($query($value) as $item) {
      if ($item instanceof \DOMElement) {
        $first = $item->firstChild;
        foreach ($this as $node) {
          if ($first) {
            $item->insertBefore($node, $first);
          } else {
            $item->appendChild($node);
          }
        }
        break;
      }
    }
    return $this;
  }

  /**
   * Get the immediately preceding sibling element of each node in the set of
   * matched nodes. Unlike "next", "prev" is not reserved by the Iterator
   * interface, however for consistency purposes this method has been named
   * following the same conventions as were used for the nextElement method.
   * Unlike it's javascript counterpart this method does not accept a css
   * selector.
   *
   * @return NodeList A list of nodes.
   */
  public function prevElement() {
    $list = new NodeList(new \DOMNodeList(), $this->query);
    foreach ($this as $node) {
      $prev = $node->previousSibling;
      while ($prev && !($prev instanceof \DOMElement)) {
        $prev = $prev->previousSibling;
      }
      if ($prev instanceof \DOMElement) {
        $list[] = $prev;
      }
    }
    return $list;
  }

  /**
   * Get all preceding sibling elements of each node in the set of matched
   * nodes. Unlike it's javascript counterpart this method does not
   * accept a css selector.
   *
   * @return NodeList A list of nodes.
   */
  public function prevAllElements() {
    $list = new NodeList(new \DOMNodeList(), $this->query);
    foreach ($this as $node) {
      $prev = $node->previousSibling;
      while ($prev) {
        if ($prev instanceof \DOMElement) {
          $list[] = $prev;
        }
        $prev = $prev->previousSibling;
      }
    }
    return $list;
  }

  /**
   * Remove the set of matched nodes from the DOM. Unlike it's javascript
   * counterpart this method does not accept a css selector.
   *
   * @return NodeList $this A list of nodes.
   */
  public function remove() {
    foreach ($this as $key => $node) {
      unset($this[$key]);
    }
    return $this;
  }

  /**
   * Remove an attribute from each element in the set of matched nodes.
   *
   * @param string $name The name of the attribute.
   * @return NodeList $this A list of nodes.
   */
  public function removeAttr($name) {
    $name = preg_split('/\s+/', (string)$name, -1, PREG_SPLIT_NO_EMPTY);
    foreach ($this as $node) {
      if ($node instanceof \DOMElement) {
        foreach ($name as $attr) {
          $node->removeAttribute($attr);
        }
      }
    }
    return $this;
  }

  /**
   * Remove a single class, multiple classes, or all classes from each element
   * in the set of matched nodes.
   *
   * @param string $cls A class name.
   * @return NodeList $this A list of nodes.
   */
  public function removeClass($cls) {
    $cls = preg_split('/\s+/', (string)$cls, -1, PREG_SPLIT_NO_EMPTY);
    foreach ($this as $node) {
      if (!($node instanceof \DOMElement)) {
        continue;
      }
      $classList = array_diff(preg_split('/\s+/', $node->hasAttribute('class') ?
        $node->getAttribute('class') : '', -1, PREG_SPLIT_NO_EMPTY), $cls);
      if (count($classList)) {
        $node->setAttribute('class', implode(' ', $classList));
      } else {
        $node->removeAttribute('class');
      }
    }
    return $this;
  }

  /**
   * Replace each element in the set of matched elements with the provided new
   * content and return the set of elements that was removed.
   *
   * @param mixed $value A node, a set of nodes, a css selector or an html
   *                     string.
   * @return NodeList
   */
  public function replaceWith($value) {
    $removed = array();
    $query = $this->query;
    foreach ($this as $key => $node) {
      $val = $query($value);
      if (count($val)) $removed[] = $node;
      foreach ($val as $i => $item) {
        if ($i) {
          /** @var NodeList $list */
          $list = new NodeList(new \DOMNodeList(), $this->query);
          $list[] = $item;
          $list->insertAfter($val[$i - 1]);
        } else {
          $this[$key] = $item;
        }
      }
    }
    return $removed;
  }

  /**
   * Get the siblings of each element in the set of matched elements, optionally
   * filtered by a selector.
   *
   * @param string $selector [optional] A css selector.
   * @return NodeList
   */
  public function siblings($selector = null) {
    return $this->parent()->children($selector);
  }

  /**
   * Add or remove one or more classes from each element in the set of matched
   * elements, depending on either the class’s presence or the value of the
   * switch argument.
   *
   * @param string $cls A class name.
   * @param bool $switch [optional] A Boolean (not just truthy/falsy) value to
   *                     determine whether the class should be added or removed.
   * @return NodeList $this
   */
  public function toggleClass($cls, $switch = null) {
    $is = is_bool($switch);
    if ($this->hasClass($cls)) {
      if (!$is || ($is && !$switch)) $this->removeClass($cls);
    } else {
      if (!$is || ($is && $switch)) $this->addClass($cls);
    }
    return $this;
  }

  /**
   * Remove the parents of the set of matched elements from the DOM, leaving the
   * matched elements in their place.
   *
   * @return NodeList $this
   */
  public function unwrap() {
    /** @var \DOMNode $node */
    foreach ($this as $node) {
      /** @var NodeList $parent */
      $parent = new NodeList(new \DOMNodeList(), $this->query);
      $parent[] = $node->parentNode;
      $parent->replaceWith($parent->children());
    }
    return $this;
  }

}